Micron Document
🎖️GitЯра🎖️

Commit d9b9ce6e8a43ada377ba1e6196d3d3352de20168


Parents : 945e7a7
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-25T14:23:37-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-25T19:23:37Z

fix(node): show node counts without truncation (#6433)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 2d173830be..4b5efa11bc 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1112,7 +1112,10 @@ no_usb_devices_found
no_usb_devices_hint
no_usb_devices_seen
### NODE ###
+node_count_online
+node_count_shown
node_count_template
+node_count_total
node_filter_exclude_infrastructure
node_filter_exclude_mqtt
node_filter_ignored

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b1c98a94c8..88c5a407a2 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1145,7 +1145,10 @@
<string name="no_usb_devices_hint">Connect a device with a USB data cable to use serial.</string>
<string name="no_usb_devices_seen">No USB devices detected</string>
<!-- NODE -->
+ <string name="node_count_online">%1$d online</string>
+ <string name="node_count_shown">%1$d shown</string>
<string name="node_count_template">(%1$d online / %2$d shown / %3$d total)</string>
+ <string name="node_count_total">%1$d total</string>
<string name="node_filter_exclude_infrastructure">Exclude infrastructure</string>
<string name="node_filter_exclude_mqtt">Exclude MQTT</string>
<string name="node_filter_ignored">You are viewing ignored nodes,\nPress to return to the node list.</string>

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummary.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummary.kt
new file mode 100644
index 0000000000..57ecc73a47
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummary.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.node_count_online
+import org.meshtastic.core.resources.node_count_shown
+import org.meshtastic.core.resources.node_count_template
+import org.meshtastic.core.resources.node_count_total
+
+/** Horizontal gap between two count labels that share a line. */
+private val LABEL_GAP = 12.dp
+
+/** Vertical gap between wrapped lines of count labels. */
+private val LINE_GAP = 2.dp
+
+/**
+ * Node totals for the Nodes list header: how many nodes are online, how many the current filter shows, and how many are
+ * known in total.
+ *
+ * This used to live in the app bar `subtitle` slot, where it was clipped by the fixed app-bar height and squeezed
+ * horizontally by the node chip plus two icon buttons — the single long string was ellipsized on most phones
+ * (issue #6268). Here it gets the full content width and, crucially, is free to grow vertically: each count is an
+ * independent label inside a [FlowRow], so at large font scales or in verbose locales the labels reflow onto extra
+ * lines instead of truncating. No `maxLines`, no ellipsis, no autosizing anywhere in this component.
+ *
+ * Screen readers get one coherent sentence via [Res.string.node_count_template] rather than three disjointed fragments.
+ */
+@Composable
+fun NodeCountSummary(onlineCount: Int, shownCount: Int, totalCount: Int, modifier: Modifier = Modifier) {
+ NodeCountSummaryContent(
+ labels =
+ listOf(
+ stringResource(Res.string.node_count_online, onlineCount),
+ stringResource(Res.string.node_count_shown, shownCount),
+ stringResource(Res.string.node_count_total, totalCount),
+ ),
+ contentDescription = stringResource(Res.string.node_count_template, onlineCount, shownCount, totalCount),
+ modifier = modifier,
+ )
+}
+
+/**
+ * Layout half of [NodeCountSummary], split out so previews can inject deliberately long strings (long locales) without
+ * needing a translated resource bundle.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+internal fun NodeCountSummaryContent(labels: List<String>, contentDescription: String, modifier: Modifier = Modifier) {
+ FlowRow(
+ modifier = modifier.clearAndSetSemantics { this.contentDescription = contentDescription },
+ horizontalArrangement = Arrangement.spacedBy(LABEL_GAP),
+ verticalArrangement = Arrangement.spacedBy(LINE_GAP),
+ ) {
+ labels.forEach { label ->
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummaryPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummaryPreviews.kt
new file mode 100644
index 0000000000..69bb29d3e7
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeCountSummaryPreviews.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("MagicNumber", "PreviewPublic")
+
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.theme.AppTheme
+
+/** English labels matching the counts from issue #6268 (76 online / 398 shown / 1247 total). */
+private val ENGLISH_LABELS = listOf("76 online", "398 shown", "1247 total")
+
+/**
+ * Deliberately verbose stand-ins for the longest Crowdin locales (German/Hungarian style compounds run roughly twice
+ * the English width). Hard-coded rather than locale-switched so the preview does not depend on a translated bundle.
+ */
+private val LONG_LOCALE_LABELS = listOf("76 Knoten verbunden", "398 Knoten angezeigt", "1247 Knoten insgesamt")
+
+private const val ENGLISH_DESCRIPTION = "(76 online / 398 shown / 1247 total)"
+
+@Composable
+private fun PreviewBody(labels: List<String>, description: String) {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.surfaceDim) {
+ NodeCountSummaryContent(
+ labels = labels,
+ contentDescription = description,
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ )
+ }
+ }
+}
+
+/** Default case on a narrow (compact-width) phone. */
+@Preview(name = "NodeCountSummary - default", widthDp = 360)
+@Composable
+fun NodeCountSummaryDefaultPreview() {
+ PreviewBody(ENGLISH_LABELS, ENGLISH_DESCRIPTION)
+}
+
+/** Narrowest realistic phone width — labels must reflow, never ellipsize. */
+@Preview(name = "NodeCountSummary - narrow 320dp", widthDp = 320)
+@Composable
+fun NodeCountSummaryNarrowPreview() {
+ PreviewBody(ENGLISH_LABELS, ENGLISH_DESCRIPTION)
+}
+
+/** Long/translated text: verbose locale on a narrow phone. */
+@Preview(name = "NodeCountSummary - long locale", widthDp = 320)
+@Composable
+fun NodeCountSummaryLongLocalePreview() {
+ PreviewBody(LONG_LOCALE_LABELS, ENGLISH_DESCRIPTION)
+}
+
+/** Accessibility text size: largest Android font scale. */
+@Preview(name = "NodeCountSummary - font scale 2x", widthDp = 360, fontScale = 2.0f)
+@Composable
+fun NodeCountSummaryLargeFontPreview() {
+ PreviewBody(ENGLISH_LABELS, ENGLISH_DESCRIPTION)
+}
+
+/** Worst case: verbose locale AND largest font scale on a narrow phone. */
+@Preview(name = "NodeCountSummary - long locale font scale 2x", widthDp = 320, fontScale = 2.0f)
+@Composable
+fun NodeCountSummaryLongLocaleLargeFontPreview() {
+ PreviewBody(LONG_LOCALE_LABELS, ENGLISH_DESCRIPTION)
+}
+
+/** Wide/tablet layout — the labels stay on one line and the block does not stretch awkwardly. */
+@Preview(name = "NodeCountSummary - wide 840dp", widthDp = 840)
+@Composable
+fun NodeCountSummaryWidePreview() {
+ PreviewBody(ENGLISH_LABELS, ENGLISH_DESCRIPTION)
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 8fee0fa3c4..f550b26584 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -63,7 +63,6 @@ import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.hop_histogram_title
-import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.node_list_help_title
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.nodes_empty_disconnected_hint
@@ -85,6 +84,7 @@ import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid
import org.meshtastic.feature.node.component.NodeContextMenu
+import org.meshtastic.feature.node.component.NodeCountSummary
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeHopHistogramSheet
import org.meshtastic.feature.node.component.NodeListHelp
@@ -168,7 +168,9 @@ fun NodeListScreen(
topBar = {
MainAppBar(
title = stringResource(Res.string.nodes),
- subtitle = stringResource(Res.string.node_count_template, onlineNodeCount, nodes.size, totalNodeCount),
+ // Node counts deliberately live in the list header (NodeCountSummary), not here: the app bar's
+ // title/subtitle slot competes for width with the node chip + action icons and is clipped to a
+ // single line, which truncated the counts (issue #6268).
ourNode = ourNode,
showNodeChip = ourNode != null && connectionState is ConnectionState.Connected,
canNavigateUp = false,
@@ -212,32 +214,43 @@ fun NodeListScreen(
stickyHeader {
val animatedAlpha by
animateFloatAsState(targetValue = if (!isScrollInProgress) 1.0f else 0f, label = "alpha")
- NodeFilterTextField(
+ Column(
modifier =
Modifier.fillMaxWidth()
.graphicsLayer(alpha = animatedAlpha)
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
- filterText = state.filter.filterText,
- onTextChange = { viewModel.nodeFilterText = it },
- currentSortOption = state.sort,
- onSortSelect = viewModel::setSortOption,
- includeUnknown = state.filter.includeUnknown,
- onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
- excludeInfrastructure = state.filter.excludeInfrastructure,
- onToggleExcludeInfrastructure = {
- viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
- },
- onlyOnline = state.filter.onlyOnline,
- onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
- onlyDirect = state.filter.onlyDirect,
- onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
- showIgnored = state.filter.showIgnored,
- onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
- ignoredNodeCount = ignoredNodeCount,
- excludeMqtt = state.filter.excludeMqtt,
- onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
- )
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ NodeCountSummary(
+ onlineCount = onlineNodeCount,
+ shownCount = nodes.size,
+ totalCount = totalNodeCount,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ )
+ NodeFilterTextField(
+ modifier = Modifier.fillMaxWidth(),
+ filterText = state.filter.filterText,
+ onTextChange = { viewModel.nodeFilterText = it },
+ currentSortOption = state.sort,
+ onSortSelect = viewModel::setSortOption,
+ includeUnknown = state.filter.includeUnknown,
+ onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
+ excludeInfrastructure = state.filter.excludeInfrastructure,
+ onToggleExcludeInfrastructure = {
+ viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
+ },
+ onlyOnline = state.filter.onlyOnline,
+ onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
+ onlyDirect = state.filter.onlyDirect,
+ onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
+ showIgnored = state.filter.showIgnored,
+ onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
+ ignoredNodeCount = ignoredNodeCount,
+ excludeMqtt = state.filter.excludeMqtt,
+ onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
+ )
+ }
}
items(nodes, key = { it.num }) { node ->

Served by rngit 1.5.0 - Generated in 0.08s